Security
Two-Factor Authentication
Structr supports two-factor authentication (2FA) using the TOTP (Time-Based One-Time Password) standard. When enabled, users must provide a code from an authenticator app in addition to their password. This adds a second layer of security that protects accounts even if passwords are compromised.
TOTP is compatible with common authenticator apps like Google Authenticator, Microsoft Authenticator, Authy, and others.
Prerequisites
Because TOTP relies on synchronized time, ensure that both the Structr server and users’ mobile devices are synced to an NTP server. Time drift of more than 30 seconds can cause authentication failures.
Configuration
Configure two-factor authentication in structr.conf or through the Configuration Interface.
Application Settings
| Setting | Default | Description |
|---|---|---|
security.twofactorauthentication.level |
1 | Enforcement level: 0 = disabled, 1 = optional (per-user), 2 = required for all users |
security.twofactorauthentication.issuer |
Structr | The issuer name displayed in authenticator apps |
security.twofactorauthentication.algorithm |
SHA1 | Hash algorithm: SHA1, SHA256, or SHA512 |
security.twofactorauthentication.digits |
6 | Code length: 6 or 8 digits |
security.twofactorauthentication.period |
30 | Code validity period in seconds |
security.twofactorauthentication.logintimeout |
300 | Time window in seconds to enter the code after password authentication |
security.twofactorauthentication.loginpage |
/twofactor | Application page for entering the two-factor code |
security.twofactorauthentication.devicetrust.enabled |
false | Enables or disables users to trust the browser they are logging in with |
security.twofactorauthentication.devicetrust.signingsecret |
Secret key that signs device trust tokens (auto-generated if not set manually) | |
security.twofactorauthentication.devicetrust.duration |
30 | Trust period in days for trusted browsers |
security.twofactorauthentication.devicetrust.cookiename |
dt_token | Name of the cookie that stores the device trust token |
Note: Changing
algorithm,digits, orperiodafter users have already enrolled invalidates their existing authenticator setup. SettwoFactorConfirmed = falseon affected users so they receive a new QR code on their next login.
Enforcement Levels
The level setting controls how two-factor authentication applies to users:
| Level | Behavior |
|---|---|
| 0 | Two-factor authentication is completely disabled |
| 1 | Optional - users can enable 2FA individually via the isTwoFactorUser property |
| 2 | Required - all users must use two-factor authentication |
User Properties
Four properties on the User type control two-factor authentication:
| Property | Type | Description |
|---|---|---|
isTwoFactorUser |
Boolean | Enables two-factor authentication for this user. Only effective when level is set to 1 (optional). |
twoFactorConfirmed |
Boolean | Indicates whether the user has completed two-factor setup. Automatically set to true after first successful 2FA login. Set to false to force re-enrollment. |
twoFactorSecret |
String | The secret key used to generate TOTP codes. Automatically generated when the user first enrolls. |
deviceTrustSecret |
String | The secret that is used to identify the user for a stored trust token. Can be used to revoke existing trust tokens for a user by calling user.rotateDeviceTrustSecret() |
Authentication Flow
The basic two-factor login process works as follows:
- User submits username and password to
/structr/rest/login - If credentials are valid and 2FA is enabled, Structr returns HTTP status 202 (Accepted)
- The response headers contain a temporary token and, for first-time setup, QR code data
- User scans the QR code with their authenticator app (first time only)
- User enters the 6-digit code from their authenticator app
- User submits the code with the temporary token to
/structr/rest/login - If the code is valid, Structr creates a session and returns HTTP status 200
Wrong Codes
A wrong code counts against the same budget a wrong password counts against,
security.passwordpolicy.maxfailedattempts. When that budget is used up the temporary token is
discarded, so the next attempt has to start again at step 1 with the password — and that step refuses
an account whose failed attempts are over the limit. Six digits with unlimited guesses would otherwise
make the second factor a delay rather than a factor.
Clear passwordAttempts on the user to lift a lockout.
The Secret is Re-Issued on Every Enrolment
Step 3 hands out the secret, and the only thing needed to get that far is the password. So every time
a QR code is issued for a user who has not confirmed yet, the secret behind it is generated anew and
the previous one stops working. Two people who both know the password can therefore never end up with
the same working secret: whoever asked last is the only one who can complete step 5.
For the user this means a QR code has to be scanned in the same login it was shown in. Starting the
login again shows a new QR code, and an authenticator entry from an earlier attempt no longer matches.
A confirmed user’s secret is never touched — it lives in their authenticator app.
Login Paths That Cannot Ask for a Code
An OAuth return, a registration confirmation link and a password reset link all identify a user
without ever asking for a code. Where the configuration requires a second factor, none of them creates
a session: the browser is redirected to security.twofactorauthentication.loginpage with a token
parameter, and the login is finished there exactly as in step 6 above.
These paths do not enrol. They carry no QR code — it would have to travel as a URL parameter, which
does not fit in a redirect — so a user who has not confirmed a second factor yet has to log in with
their password once, which is the flow that can enrol them.
Implementation
To implement two-factor authentication in your application, you need two pages: a login page and a two-factor code entry page.
Login Page
Create a login form that detects the two-factor response. When the server returns status 202, redirect to the two-factor page with the token, deviceTrustPossible and optional QR data as URL parameters.
JavaScript:
async function login(username, password) {
const response = await fetch('/structr/rest/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: username,
password: password
})
});
if (response.status === 202) {
// Two-factor authentication required
const token = response.headers.get('token');
const qrdata = response.headers.get('qrdata') || '';
const twoFactorPage = response.headers.get('twoFactorLoginPage');
const deviceTrustPossible = response.headers.get('deviceTrustPossible');
const deviceTrustDuration = response.headers.get('deviceTrustDuration');
window.location.href = `${twoFactorPage}?token=${token}&qrdata=${qrdata}&deviceTrustPossible=${deviceTrustPossible}&deviceTrustDuration=${deviceTrustDuration}`;
} else if (response.ok) {
// Login successful, no 2FA required
window.location.href = '/';
} else {
// Login failed
const error = await response.json();
console.error('Login failed:', error);
}
}
curl:
curl -si http://localhost:8082/structr/rest/login \
-X POST \
-H "Content-Type: application/json" \
-d '{"name": "user", "password": "password"}'
When two-factor authentication is required, the response looks like:
HTTP/1.1 202 Accepted
token: eyJhbGciOiJIUzI1NiJ9...
twoFactorLoginPage: /twofactor
deviceTrustPossible: true
deviceTrustDuration: 30
qrdata: iVBORw0KGgoAAAANSUhEUgAA...
The response headers contain:
| Header | Description |
|---|---|
token |
Temporary token for the two-factor login (valid for the configured timeout period) |
twoFactorLoginPage |
The configured page for entering the two-factor code |
deviceTrustPossible |
If device trust is possible according to the configuration |
deviceTrustDuration |
Trust duration in days |
qrdata |
Base64-encoded PNG image of the QR code (only present if twoFactorConfirmed is false for the user) |
Two-Factor Page
Create a page that displays the QR code for first-time setup and accepts the TOTP code.
Example HTML Structure
<!DOCTYPE html>
<html>
<head>
<title>Two-Factor Authentication</title>
</head>
<body>
<h1>Two-Factor Authentication</h1>
<div id="setup-instructions" style="display: none;">
<p>Scan this QR code with your authenticator app:</p>
<img id="qrcode" alt="QR Code" />
<p>Then enter the 6-digit code shown in your app.</p>
</div>
<form id="twoFactorForm">
<label for="code">Authentication Code:</label>
<input type="text" id="code" name="code"
pattern="[0-9]{6,8}" maxlength="8"
autocomplete="one-time-code" required />
<label id="trust-device-wrapper" class="flex items-center" style="display: none;">
<input type="checkbox" id="trust-device" name="trustDevice" />
<span>Trust device</span>
</label>
<button type="submit">Verify</button>
</form>
<p id="error" style="color: red;"></p>
<script>
document.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(location.search);
const token = params.get('token');
const qrdata = params.get('qrdata');
const deviceTrustPossible = params.get('deviceTrustPossible') === 'true';
const deviceTrustDuration = params.get('deviceTrustDuration');
// Display QR code for first-time setup
if (qrdata) {
const qrImage = document.getElementById('qrcode');
// Convert URL-safe base64 back to standard base64
const standardBase64 = qrdata.replaceAll('_', '/').replaceAll('-', '+');
qrImage.src = 'data:image/png;base64,' + standardBase64;
qrImage.style.display = 'block';
document.getElementById('setup-instructions').style.display = 'block';
}
if (deviceTrustPossible) {
document.querySelector('#trust-device-wrapper').style.display = null;
document.querySelector('#trust-device-wrapper span').textContent += ' for ' + deviceTrustDuration + ' days';
}
// Handle form submission
document.getElementById('twoFactorForm').addEventListener('submit', async (event) => {
event.preventDefault();
const code = document.getElementById('code').value;
const trustChecked = document.getElementById('trust-device').checked;
const response = await fetch('/structr/rest/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
twoFactorToken: token,
twoFactorCode: code,
trustDevice: trustChecked
})
});
if (response.ok) {
window.location.href = '/';
} else {
document.getElementById('error').textContent = 'Invalid code. Please try again.';
}
});
});
</script>
</body>
</html>
curl:
curl -si http://localhost:8082/structr/rest/login \
-X POST \
-H "Content-Type: application/json" \
-d '{"twoFactorToken": "eyJhbGciOiJIUzI1NiJ9...", "twoFactorCode": "123456", "trustDevice": true}'
Managing User Enrollment
Enabling 2FA for a User
When the enforcement level is set to 1 (optional), enable two-factor authentication for individual users by setting isTwoFactorUser to true.
curl:
curl -X PUT http://localhost:8082/structr/rest/User/<UUID> \
-H "Content-Type: application/json" \
-H "X-User: admin" \
-H "X-Password: admin" \
-d '{"isTwoFactorUser": true}'
JavaScript:
await fetch('/structr/rest/User/<UUID>', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
isTwoFactorUser: true
})
});
The user will see the QR code on their next login.
Re-Enrolling a User
To force a user to set up two-factor authentication again (for example, if they lost their phone), set twoFactorConfirmed to false:
curl:
curl -X PUT http://localhost:8082/structr/rest/User/<UUID> \
-H "Content-Type: application/json" \
-H "X-User: admin" \
-H "X-Password: admin" \
-d '{"twoFactorConfirmed": false}'
The user will receive a new QR code on their next login. Their authenticator app will need to be updated with the new secret.
Disabling 2FA for a User
To disable two-factor authentication for a user (when level is 1):
curl:
curl -X PUT http://localhost:8082/structr/rest/User/<UUID> \
-H "Content-Type: application/json" \
-H "X-User: admin" \
-H "X-Password: admin" \
-d '{"isTwoFactorUser": false}'
IP Whitelisting (removed)
Earlier versions could skip the second factor for addresses listed in security.twofactorauthentication.whitelistedIPs. That setting no longer exists. The address it matched was read from the X-Forwarded-For header, which a client sets itself, so anyone who knew a listed address and a password could send that header and log in without a code. Structr logs a warning at startup if the key is still present in structr.conf, and requests from those addresses are asked for a code like any other.
Use Trusted Devices below to spare a known browser the code, or a reverse proxy in front of Structr if access really has to be decided by network address.
Trusted Devices
Device trust functionality can be enabled via the configuration setting security.twofactorauthentication.devicetrust.enabled and configured per-user via the attribute deviceTrustPossible. The login form above auto-adapts and shows a “Trust Device” checkbox stating the configured trust duration.
If the user logs in via 2FA successfully and requests device trust, a trust cookie (security.twofactorauthentication.devicetrust.cookiename) is set for the user’s browser. This browser is then fingerprinted and trusted for the configured number of days (security.twofactorauthentication.devicetrust.duration) and the login requests for that user from that browser proceed with password-authentication only.
The browser fingerprint includes browser name, browser major version, operating system name, operating system major version, and device class. If any of these fields change, the trust cookie becomes invalid.
Disabling device trust (via security.twofactorauthentication.devicetrust.enabled or the per-user attribute) does not invalidate already-issued device trust cookies. It suspends the device trust feature and requires 2FA login even if the user has a valid device trust cookie. If device trust is enabled again, previously issued trust cookies are used again.
Device trust tokens are signed with a global signing secret (security.twofactorauthentication.devicetrust.signingsecret) which is automatically created if none is set. Changing this secret revokes and invalidates all trust cookies for all users.
A user’s trust cookies can be revoked by calling user.rotateDeviceTrustSecret(), which generates a new secret and invalidates all previously issued cookies for that user only.
Troubleshooting
Invalid Code Errors
If users consistently receive “invalid code” errors:
- Check time synchronization - The most common cause is time drift between the server and the user’s device. Ensure both are synced to NTP.
- Verify the period setting - If you changed
security.twofactorauthentication.period, users need to re-enroll. - Check the algorithm - Some older authenticator apps only support SHA1.
Lost Authenticator Access
If a user loses access to their authenticator app:
- An administrator sets
twoFactorConfirmed = falseon the user - The user logs in with username and password
- The user scans the new QR code with their authenticator app
- The user completes the login with the new code
QR Code Not Displaying
If the QR code does not display:
- Check that
qrdatais present in the response headers - Verify the base64 conversion (URL-safe to standard)
- Ensure the
twoFactorConfirmedproperty is false
Related Topics
- User Management - User properties and account security
- JWT Authentication - Token-based authentication
- OAuth - Authentication with external providers